---
title: 给博客添加一个文章数据统计的页面
description: "在这篇文章中，我将介绍如何为博客添加一个数据统计页面，包括发文统计、分类统计和标签统计等功能。这个功能可以帮助我们更好地了解博客的内容分布和写作情况。"
date: "2025-03-31"
lang: zh
tags:
  - 博客
  - 数据统计
  - react
category: 技术
cover: https://cdn.qladgk.com/images/20250508163854666.png
---

import Image from "@/components/mdx/Image";

<Image
  src="https://cdn.qladgk.com/images/20250508163854666.png"
  alt="cover"
  width={999}
  height={527}
  isArticleImage={true}
/>

## 概览

感谢大大的小蜗牛的 [文章](https://www.eallion.com/blog-heatmap/) 和 [仓库](https://github.com/eallion/eallion.com/blob/main/layouts/_default/stats.html)

### 最终效果

https://www.qladgk.com/stats

<Image
  src="https://cdn.qladgk.com/images/20250331194829358.png"
  alt="博客数据统计页面截图"
  width={500}
  height={527}
  isArticleImage={true}
/>

### 功能概述

统计页面包含四个核心模块：

| 模块名称 | 可视化形式   | 数据维度     | 交互功能     |
| -------- | ------------ | ------------ | ------------ |
| 写作日历 | 热力图       | 每日写作情况 | 悬停显示详情 |
| 分类占比 | 扇形图       | 文章分类分布 | 高亮显示     |
| 年度统计 | 柱状图       | 历年文章数量 | 自适应缩放   |
| 标签云   | 标签频率展示 | 标签使用频率 | 点击跳转     |

### 技术架构

我采用了纯静态生成方案来实现统计功能，主要考虑以下几点：

1. 性能优势：所有数据在构建时生成，运行时无需查询
2. 部署简单：不需要额外的数据库或服务器
3. 易于维护：数据随博客内容自动更新

<Image
  src="https://cdn.qladgk.com/images/20250331214432210.png"
  alt="功能架构"
  width={999}
  height={527}
  isArticleImage={true}
/>

整个功能分为四个核心模块，每个模块都有其特定的实现方式和展示效果...

[查看完整代码](https://github.com/qlAD/gkBlog/commit/ec9957aeffc576e2679bffbfa275c7fee0269ac4)

## 数据收集

数据收集是整个统计功能的基础。由于博客采用 MDX 文件存储方式，我们可以利用 Node.js 的文件系统功能来读取和处理数据。

<Image
  src="https://cdn.qladgk.com/images/20250331200608795.png"
  alt="文章存储结构示例"
  width={500}
  height={300}
  isArticleImage={true}
/>

### 文件读取

首先需要递归遍历 blog 目录来获取所有的 MDX 文件。这里使用 `fs.readdirSync` 配合递归实现，确保能获取到所有层级的文章：

```tsx
// filepath: /apps/gkBlog/src/pages/stats.tsx
import fs from "fs";
import path from "path";
import frontMatter from "front-matter";

function getAllMdxFiles(directory: string): string[] {
  const files: string[] = [];
  const entries = fs.readdirSync(directory, { withFileTypes: true });

  entries.forEach((entry) => {
    const fullPath = path.join(directory, entry.name);
    if (entry.isDirectory()) {
      files.push(...getAllMdxFiles(fullPath));
    } else if (entry.isFile() && entry.name === "index.mdx") {
      files.push(fullPath);
    }
  });

  return files;
}
```

### 数据解析

获取到文件路径后，需要解析每个文件的内容。这里我们关注两个部分：

1. frontmatter 中的元数据（日期、标签等）
2. 文章内容的字数统计

使用 `front-matter` 库来解析文件内容：

```tsx
// filepath: /apps/gkBlog/src/pages/stats.tsx
// 文章前置数据类型定义
type PostFrontMatter = {
  date: string;
  title: string;
  category: string;
  tags: string[];
};

// 在 getStaticProps 中解析数据
export async function getStaticProps() {
  const postsDirectory = path.join(process.cwd(), "src/pages/blog");
  const filePaths = getAllMdxFiles(postsDirectory);

  const allPostsData = filePaths.map((filePath) => {
    const fileContents = fs.readFileSync(filePath, "utf8");
    const { attributes, body } = frontMatter<PostFrontMatter>(fileContents);

    return {
      ...attributes,
      wordCount: body.replace(/\s+/g, "").length,
      tags: attributes.tags || [],
    };
  });

  return {
    props: {
      allPostsData,
    },
  };
}
```

### 统计处理

有了原始数据后，我们需要进行多个维度的统计。主要包括：

- 基础统计：文章总数、分类数量等
- 时间维度：年度文章数量分布
- 分类维度：不同分类的文章占比

```tsx
// filepath: /apps/gkBlog/src/pages/stats.tsx
function StatsPage({ allPostsData }: { allPostsData: PostData[] }) {
  const stats = {
    totalPosts: allPostsData.length,
    totalCategories: new Set(allPostsData.map(post => post.category)).size,
    totalTags: new Set(allPostsData.flatMap(post => post.tags)).size,
    totalWordCount: allPostsData.reduce((sum, post) => sum + post.wordCount, 0),

    // 文章年度分布
    postsByYear: getPostsByYear(allPostsData),

    // 分类统计
    postsByCategory: getPostsByCategory(allPostsData),

    // 标签统计
    tags: allPostsData.flatMap(post => post.tags)
  };

  return (
    // ...渲染统计组件
  );
}
```

这里使用了 `Set` 来计算独特的分类和标签数量，并使用 `reduce` 来计算总字数。我们还将文章按年份和分类进行统计，以便后续使用。

到此为止，我们已经收集了所有必要的数据。接下来，就可以使用这些数据来创建各种图表和可视化组件。

## 热力图实现

热力图是一个类似 GitHub 提交记录的可视化组件，它能直观地展示写作频率和强度。实现这个组件需要考虑几个关键点：

1. 时间范围的确定（过去一年）
2. 数据的颜色映射（字数多少对应不同深浅）
3. 交互效果（悬停显示详情）

<Image
  src="https://cdn.qladgk.com/images/20250331202554218.png"
  alt="热力图示例"
  width={999}
  height={300}
  isArticleImage={true}
/>

### 实现代码

热力图的核心实现包括日期单元格的创建和提示框的显示：

```tsx
// filepath: /apps/gkBlog/src/components/stats/Heatmap.tsx
function createDay({ date, title, count, posts }: DayProps) {
  const day = document.createElement("div");
  day.className = cn(
    "heatmap_day",
    count === 0 && "heatmap_day_level_0",
    count > 0 && count < 1000 && "heatmap_day_level_1",
    count >= 1000 && count < 2000 && "heatmap_day_level_2",
    count >= 2000 && count < 3000 && "heatmap_day_level_3",
    count >= 3000 && "heatmap_day_level_4"
  );

  const tooltip = createTooltip(title, count, posts);
  day.appendChild(tooltip);

  return day;
}

export function Heatmap({ data }: HeatmapProps) {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!containerRef.current) return;
    const container = containerRef.current;
    container.innerHTML = "";

    // 创建星期标签
    const weekLabels = document.createElement("div");
    weekLabels.className =
      "absolute -left-6 top-0 flex flex-col gap-2 text-xs text-gray-400";

    // 添加星期标签
    WEEK_DAYS.forEach((day) => {
      const label = document.createElement("div");
      label.className = "h-3";
      label.textContent = day;
      weekLabels.appendChild(label);
    });
    container.appendChild(weekLabels);

    // 创建日期网格
    let currentDate = new Date(startDate);
    while (currentDate <= today) {
      const dateString = formatDate(currentDate);
      const dayData = data.find((d) => d.date === dateString);

      container.appendChild(
        createDay({
          date: dateString,
          count: dayData?.wordCount || 0,
          title: dayData?.title,
          posts: dayData ? 1 : 0,
        })
      );

      currentDate.setDate(currentDate.getDate() + 1);
    }
  }, [data]);

  return (
    <div className="relative pl-8">
      <div ref={containerRef} className="heatmap" />
    </div>
  );
}
```

### 样式处理

热力图的样式主要分为三部分：基础布局、颜色层级和提示框：

```css
/* 热力图基础布局 */
.heatmap {
  display: grid;
  grid-template-columns: repeat(53, 1fr);
  gap: 2px;
}

/* 颜色层级 */
.heatmap_day_level_0 {
  background-color: #ebedf0;
}
.dark .heatmap_day_level_0 {
  background-color: #2d333b;
}

.heatmap_day_level_4 {
  background-color: #216e39;
}
.dark .heatmap_day_level_4 {
  background-color: #4ae883;
}

/* 提示框样式 */
.heatmap_tooltip {
  position: absolute;
  bottom: 100%;
  left: 50%;
  transform: translateX(-50%);
  opacity: 0;
  transition: opacity 0.2s ease;
}

.heatmap_day:hover .heatmap_tooltip {
  opacity: 1;
}
```

## 扇形图和柱状图

### 扇形图实现

扇形图使用 SVG 绘制，通过计算每个分类的占比来生成扇形路径。这里的关键点是：

1. 计算每个扇形的角度和路径
2. 设置合适的颜色区分
3. 添加交互动画效果

<Image
  src="https://cdn.qladgk.com/images/20250331211057803.png"
  alt="扇形图示例"
  width={999}
  height={400}
  isArticleImage={true}
/>

```tsx
// filepath: /apps/gkBlog/src/components/stats/PieChart.tsx
import { cn } from "@/lib/utils";

interface PieChartProps {
  data: Array<{
    category: string;
    count: number;
  }>;
}

export function PieChart({ data }: PieChartProps) {
  const total = data.reduce((sum, item) => sum + item.count, 0);
  const colors = ["#e34c26", "#f1e05a", "#2b7489", "#3572A5"];

  const getPath = (percentage: number, offset: number) => {
    const r = 40;
    const cx = 50,
      cy = 50;
    const startAngle = (offset * 3.6 - 90) * (Math.PI / 180);
    const endAngle = ((offset + percentage) * 3.6 - 90) * (Math.PI / 180);

    return [
      `M ${cx} ${cy}`,
      `L ${cx + r * Math.cos(startAngle)} ${cy + r * Math.sin(startAngle)}`,
      `A ${r} ${r} 0 ${percentage > 50 ? 1 : 0} 1 ${cx + r * Math.cos(endAngle)} ${cy + r * Math.sin(endAngle)}`,
      "Z",
    ].join(" ");
  };

  return (
    <svg viewBox="0 0 100 100">
      {data.map(({ category, count }, index) => {
        const percentage = (count / total) * 100;
        const offset = data
          .slice(0, index)
          .reduce((sum, item) => sum + (item.count / total) * 100, 0);

        return (
          <path
            key={category}
            d={getPath(percentage, offset)}
            fill={colors[index % colors.length]}
            className={cn(
              "transition-all duration-300",
              "hover:scale-105 hover:brightness-110",
              "origin-center cursor-pointer"
            )}
          />
        );
      })}
    </svg>
  );
}
```

### 柱状图实现

柱状图使用 div 元素实现，主要考虑以下几点：

1. 固定显示近20年的数据范围
2. 根据最大值动态计算高度
3. 支持横向滚动和响应式布局
4. 添加悬停效果和年份标签

<Image
  src="https://cdn.qladgk.com/images/20250331211622277.png"
  alt="柱形图示例"
  width={999}
  height={400}
  isArticleImage={true}
/>

```tsx
// filepath: /apps/gkBlog/src/components/stats/BarChart.tsx
interface BarChartProps {
  data: Array<{
    year: string;
    count: number;
  }>;
}

export function BarChart({ data }: BarChartProps) {
  const years = Array.from(
    { length: 20 },
    (_, i) => new Date().getFullYear() - 19 + i
  ).map(String);

  const maxCount = Math.max(...data.map((item) => item.count));

  return (
    <div className="overflow-x-auto scrollbar-thin">
      <div className="relative min-w-[800px] h-[300px]">
        {years.map((year) => {
          const item = data.find((d) => d.year === year);
          const height = item ? (item.count / maxCount) * 100 : 0;

          return (
            <div key={year} className="group relative inline-block w-8">
              <div
                className="bg-blue-500 hover:brightness-110 absolute bottom-0 w-6"
                style={{ height: `${height}%` }}
              />
              <span className="absolute -bottom-6 -rotate-45 text-sm">
                {year}
              </span>
            </div>
          );
        })}
      </div>
    </div>
  );
}
```

### 样式处理

为图表组件添加必要的样式支持：

```css
// filepath: /apps/gkBlog/src/styles/stats.css
/* 图表容器 */
.charts-container {
  display: grid;
  gap: 2rem;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}

/* 扇形图样式 */
.pie-chart {
  aspect-ratio: 1;
  max-width: 300px;
  margin: 0 auto;
}

.pie-chart path {
  transition: all 0.3s ease;
}

/* 柱状图样式 */
.bar-chart {
  width: 100%;
  overflow-x: auto;
  scrollbar-width: thin;
}

.bar-chart-container {
  padding-bottom: 2rem;
}

/* 深色模式适配 */
@media (prefers-color-scheme: dark) {
  .bar-chart::-webkit-scrollbar-track {
    background: #1a1a1a;
  }
}
```

## 标签云和状态

## 标签云实现

标签云采用类似 shields.io 的样式，根据使用频率设置不同的显示效果：

<Image
  src="https://cdn.qladgk.com/images/20250331211529763.png"
  alt="标签云示例"
  width={999}
  height={200}
  isArticleImage={true}
/>

```tsx
// filepath: /apps/gkBlog/src/components/stats/TagCloud.tsx
interface TagCloudProps {
  tags: string[];
}

export function TagCloud({ tags }: TagCloudProps) {
  const sortedTags = Object.entries(
    tags.reduce<Record<string, number>>((acc, tag) => {
      acc[tag] = (acc[tag] || 0) + 1;
      return acc;
    }, {})
  )
    .map(([name, count]) => ({ name, count }))
    .sort((a, b) => b.count - a.count);

  const maxCount = Math.max(...sortedTags.map((t) => t.count));

  return (
    <div className="flex flex-wrap gap-2">
      {sortedTags.map(({ name, count }) => (
        <a
          key={name}
          href={`/blog/tag/${encodeURIComponent(name)}`}
          className="inline-flex hover:scale-105 transition-transform"
        >
          <span className="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded-l-md">
            {name}
          </span>
          <span
            className={cn(
              "px-2 py-1 text-white rounded-r-md",
              getTagColorClass(count, maxCount)
            )}
          >
            {count}
          </span>
        </a>
      ))}
    </div>
  );
}
```

### 状态展示

使用 shields.io 生成博客的各项状态指标：

```tsx
<img
  alt="License"
  src="https://img.shields.io/badge/License-MIT-green"
  className="h-5"
/>
<img
  alt="WebSite"
  src="https://img.shields.io/website?style=flat-square&url=https%3A%2F%2Fwww.qladgk.com"
  className="h-5"
/>
```

## 总结

通过这次更新，我实现了一个完全静态生成的博客统计页面。整个实现过程主要关注以下几点：

### 数据处理

- 使用 `fs` 模块递归读取 MDX 文件
- 通过 `front-matter` 解析文章元数据
- 利用 `Set` 和 `reduce` 处理统计数据

### 可视化实现

- 热力图：展示写作频率
- 扇形图：分类占比分布
- 柱状图：年度数据趋势
- 标签云：标签使用频率

### 交互优化

- 统一的动画过渡效果
- 合适的悬停交互
- 深色模式适配
- 响应式布局

### 后续规划

计划添加以下功能：

- 文章阅读量统计
- 评论数据分析

[查看完整代码](https://github.com/qlAD/gkBlog/commit/ec9957aeffc576e2679bffbfa275c7fee0269ac4)
